ARM 架构简述
ARM 架构,过去称作进阶精简指令集机器(Advanced RISC Machine,更早称作艾康精简指令集机器, Acorn RISC Machine),是一个精简指令集(RISC)处理器架构家族,其广泛地使用在许多嵌入式系统设计
ARM程序,指在ARM系统中正在执行的程序,而非保存在ROM中的bin文件
ARM 和 x86 之间的更多区别是:
- 在 ARM 中,大多数指令都可用于条件执行。
- 英特尔 x86 和 x86-64 系列处理器使用小端格式
- ARM 架构在低版本是小端的,之后,ARM 处理器成为 BL端(有允许可切换字节序的设置,由程序状态寄存器 CPSR 的位9 (E位) 控制)
一个ARM程序包含3部分:
- RO段(只读):RO是程序中的指令和常量
- RW段(可读写):RW是程序中已初始化的变量
- ZI段(可读写):ZI是程序中未初始化的变量
搭建环境
安装 qemu-user:(用于开启虚拟机)
1 | sudo apt-get install qemu-user |
安装交叉编译工具 aarch64-linux-gnu-gcc:(推荐)
1 | sudo apt-get install gcc-aarch64-linux-gnu |
安装交叉编译工具链 arm-linux-gcc-4.3.3:(不推荐)
- arm-linux-gcc-4.4.3.tar.gz
- 百度云盘,密码:1gtt
1 | sudo mkdir /usr/local/arm_4.4.3 |
1 | export PATH=$PATH:/usr/local/arm_4.4.3/bin |
安装 qemu 内核:(最好不要 apt-get
直接安装,因为没有 qemu-system-aarch64
)
1 | wget https://download.qemu.org/qemu-6.2.0.tar.xz |
获取 ubuntu-18.04-server-arm64.iso:
下载对应架构(aarch64)的 UEFI 固件:
1 | wget http://releases.linaro.org/components/kernel/uefi-linaro/16.02/release/qemu64/QEMU_EFI.fd |
创建虚拟机硬盘:
1 | qemu-img create ubuntuimg.img 40G |
创建虚拟机:
1 | qemu-system-aarch64 -m 2048 -cpu cortex-a57 -smp 2 -M virt -bios QEMU_EFI.fd -nographic -drive if=none,file=ubuntu-18.04-server-arm64.iso,id=cdrom,media=cdrom -device virtio-scsi-device -device scsi-cd,drive=cdrom -drive if=none,file=ubuntuimg.img,id=hd0 -device virtio-blk-device,drive=hd0 |
直接回车选择安装 Ubuntu Server:
安装成功后,就可以通过如下命令开启 ARM 程序:
1 | qemu-arm -L /usr/aarch64-linux-gnu -g 1234 ./[pwn] |
最后介绍一下调试方法:
1 | gdb-multiarch [pwn] |
参考:
数据类型和寄存器
与高级语言类似,ARM 支持对不同数据类型的操作
我们可以加载(或存储)的数据类型可以是有符号和无符号单词,半字或字节,这些数据类型的扩展名是:
- -h 或 -sh 表示半字
- -b 或 -sb 表示一字节
- 没有扩展名表示一字
有符号数据类型和无符号数据类型之间的区别在于:
- 有符号数据类型 (+s) 可以同时包含正值和负值,因此范围较小
- 无符号数据类型 (+0) 可以保存较大的正值(包括“零”),但不能保存负值,因此范围更广
以下是如何将这些数据类型与加载和存储说明一起使用的一些示例:
1 | ldr = Load Word |
寄存器的数量取决于ARM版本
根据ARM参考手册,除了基于 ARMv6-M
和 ARMv7-M
的处理器外,都有30个通用的32位寄存器,其中的 r0-r15
作用如下表:
32位 | 64位 | 别名 | 目的 |
---|---|---|---|
R0-R6 | X0-X7 | – | 一般用途 |
X8 | – | 保存子程序返回值 | |
R7 | – | 持有系统调用号 | |
X9-X15 | 临时寄存器 | 子程序使用时不需要保存 | |
R8-R10 | X19-X28 | 临时寄存器 | 子程序使用时必须保存 |
X18 | – | 记录平台信息 | |
R11 | X29 | FP | 帧指针 |
R12 | X16-X17 | IP | 程序内呼叫 |
R13 | X31 | SP | 栈指针 |
R14 | X30 | LR | 链接注册 |
R15 | PC | 程序计数器 | |
CPSR | CPSR | – | 当前程序状态寄存器 |
SPSR | SPSR | – | 程序状态保存寄存器 |
- R0-R12:可在常见操作期间用于存储临时值、指针(到内存的位置)等
- R0 在算术运算期间可以称为累加器,或者用于存储以前调用的函数的结果
- R7 在使用系统调用时变得很有用,因为它存储了 syscall 编号
- R11 帮助我们跟踪栈上的边界,作为帧指针(稍后将介绍)
- R0-R3:ARM 上的函数调用约定指定函数的前四个参数存储在寄存器 r0-r3 中
- R13:SP(栈指针)栈指针指向栈的顶部
- 栈是用于特定于函数的存储的内存区域,当函数返回时,将回收该内存区域
- 因此,栈指针用于分配栈上的空间,方法是从栈指针中减去我们要分配的值(以字节为单位),换句话说,如果我们想分配一个32位值,我们从栈指针中减去“4”
- R14:LR(链路寄存器)进行函数调用时,链接寄存器将使用内存地址进行更新,该内存地址引用从中启动函数的下一条指令
- 这样做允许程序返回到在“子”函数完成后启动“子”函数调用的“父”函数
- R15:PC(程序计数器)程序计数器按执行的指令的大小自动递增
- 此大小在
ARM
状态下始终为4个字节,在THUMB
模式下始终为2个字节 - 当执行分支指令时,PC保存目标地址,在执行过程中,PC 将当前指令加 8(两条 ARM 指令)的地址存储在
ARM
状态,将当前指令加 4(两条拇指指令)的地址存储在Thumb(v1)
状态 - PS:这与x86不同,在x86中,PC 始终指向要执行的下一条指令
- 此大小在
CPSR:显示当前程序状态寄存器(CPSR)的值
- 在此值下,您可以看到标志 >>
thumb, fast, interrupt, overflow, carry, zero, and negative
- 这些标志表示 CPSR 寄存器中的某些位,并根据 CPSR 的值进行设置,并在激活时变为 粗体
- N、Z、C 和 V 位与 x86 上 EFLAG 寄存器中的 SF、ZF、CF 和 OF 位相同,它们将用于支持程序集级别的条件和循环中的条件执行:
- N – 当操作结果为负时设置
- Z – 当操作结果为零时设置
- C – 判断无符号数是否溢出
- V – 判断带符号数是否溢出
下表展示了 CPSR 中各个位的具体作用:
Flag | Description |
---|---|
N (Negative) |
如果指令结果产生负数,则启用 |
Z (Zero) |
如果指令的结果产生零值,则启用 |
C (Carry) |
如果指令的结果产生一个需要完全表示第 33 位的值,则启用该值 |
V (Overflow) |
如果指令的结果产生的值不能用 32 位 2 的补码表示,则启用此选项 |
E (Endian-bit) |
ARM可以在小端序或大端中进行切换,对于小字节序,此位设置为“0”,对于大字节序模式,此位设置为“1” |
T (Thumb-bit) |
如果您处于 Thumb 状态,则设置此位,并在您处于 ARM 状态时被禁用 |
M (Mode-bits) |
这些位指定当前权限模式(USR、SVC 等) |
J (Jazelle) |
第三个执行状态,允许某些 ARM 处理器在硬件中执行 Java 字节码 |
ARM & THUMB
ARM 处理器有两种主要状态,它们可以在其中运行(这里不算Jazelle):ARM 和 Thumb
- Thumb 是 ARM 体系结构中一种 16位的指令集
- Thumb 指令集可以看作是 ARM 指令压缩形式的子集,它是为减小代码量而提出,具有16bit的代码密度
这些状态与权限级别无关,例如,在 SVC 模式下运行的代码可以是 ARM 或 Thumb,这两种状态之间的主要区别在于指令集,其中 ARM 状态下的指令始终为32位,而 Thumb 状态的指令为16位(但可以是32位)
了解何时以及如何使用 Thumb 对于我们的 ARM 漏洞利用开发目的尤为重要,在编写 ARM 外壳代码时,我们需要删除 NULL 字节,并使用 16 位 Thumb 指令而不是 32 位 ARM 指令来降低拥有它们的机会
如前所述,有不同的 Thumb 版本,不同的命名只是为了将它们彼此区分开来:(处理器本身将始终将其称为 Thumb)
- Thumb-1(16 位指令):用于 ARMv6 和早期架构
- Thumb-2(16 位和 32 位指令):通过添加更多指令并允许它们为 16 位或 32 位宽(ARMv6T2、ARMv7)来扩展 Thumb-1
- ThumbEE:包括一些针对动态生成的代码(在执行前或执行期间在设备上编译的代码)的一些更改和添加
ARM 和 Thumb 之间的区别:
- 条件执行:
- ARM 状态下的所有指令都支持条件执行,某些 ARM 处理器版本允许使用 IT 指令在 Thumb 中执行条件(条件执行导致更高的代码密度,因为它减少了要执行的指令数量,并减少了昂贵的分支指令的数量)
- 32 位 ARM 和 Thumb 指令:
- 32 位 Thumb 指令具有 .w 后缀
- 32 位 ARM 指令没有
- 桶形移位器:
- 是另一个独特的 ARM 模式功能,它可用于将多个指令缩小为一个
要切换处理器执行的状态,必须满足以下两个条件之一:
- 我们可以使用分支指令BX(分支和交换)或BLX(分支,链路和交换),并将目标寄存器的最低有效位设置为1
- 这可以通过将1添加到偏移量(如0x5530 + 1)来实现
- 您可能会认为这会导致对齐问题,因为指令是 2 字节或 4 字节对齐的
- 不够都这不是问题,因为处理器将忽略最低有效位
- 我们知道,如果当前程序状态寄存器中的 T 位已设置,则处于 Thumb 模式
ARM 指令集
汇编语言由指令组成,这些指令是主要的构建块,ARM 指令后面通常跟一个或两个操作数,通常使用以下模板:
1 | MNEMONIC{S}{condition} {Rd}, Operand1, Operand2 |
由于 ARM 指令集的灵活性,并非所有指令都使用模板中提供的所有字段
1 | MNEMONIC - 指令的简称(助记符) |
虽然 MNEMONIC、S、Rd 和 Operand1 字段是直截了当的,但条件和 Operand2 字段需要进一步澄清:
- 条件字段与 CPSR 寄存器的值紧密相关,或者更确切地说,与寄存器中特定位的值紧密相关
- Operand2 被称为灵活的操作数,因为我们可以以各种形式使用它:
- 作为即时值(具有有限的值集)
- 寄存器
- 以移位寄存器
- 例如,我们可以将这些表达式用作操作数 2:
1 | #123 - 立即值(具有有限的一组值) |
- 作为不同类型说明的快速示例,让我们看一下以下列表:
1 | ADD R0, R1, R2 - 将 R1 (Operand1) 和 R2 (Operand2 的寄存器形式) 的内容相加并将结果存储到 R0 (Rd) |
作为快速摘要,让我们看一下我们将在以后的示例中使用的最常见的说明
指令 | 描述 | 指令 | 描述 |
---|---|---|---|
MOV | Move data | EOR | Bitwise XOR |
MVN | Move and negate | LDR | Load |
ADD | Addition | STR | Store |
SUB | Subtraction | LDM | Load Multiple |
MUL | Multiplication | STM | Store Multiple |
LSL | Logical Shift Left | PUSH | Push on Stack |
LSR | Logical Shift Right | POP | Pop off Stack |
ASR | Arithmetic Shift Right | B | Branch |
ROR | Rotate Right | BL | Branch with Link |
CMP | Compare | BX | Branch and eXchange |
AND | Bitwise AND | BLX | Branch with Link and eXchange |
ORR | Bitwise OR | SWI/SVC | System Call |
加载和存储
ARM 使用 load-store 模型进行内存访问,这意味着只有 load/store(LDR 和 STR)指令才能访问内存,虽然在x86上允许大多数指令直接对内存中的数据进行操作,但在ARM上,数据必须在操作之前从内存移动到寄存器中
这意味着在 ARM 上的特定内存地址递增 32 位值需要三种类型的指令(加载、递增和存储),首先将特定地址的值加载到寄存器中,在寄存器中递增,然后将其从寄存器存储回存储器
通常,LDR
用于将某些内容从内存加载到寄存器中,而 STR
用于将某些内容从寄存器存储到内存地址
常规操作
1 | LDR R2, [R0] @ [R0]:原始地址是在 R0 中找到的值 |
- LDR 操作:将 R0 中找到的地址处的值加载到目标寄存器 R2
- STR 操作:将 R2 中找到的值存储到 R1 中找到的内存地址
1 | STR Ra, [Rb, imm] |
- 偏移形式为:使用立即(整数)作为偏移量
- 偏移地址模式:先从基本寄存器 Ra 中添加或减去此值,然后再使用已知的偏移量访问数据
1 | STR Ra, [Rb, Rc] |
- 偏移形式为:使用寄存器作为偏移(偏移地址模式)
- Rb 是基寄存器
- Rc 是偏移
- 使用场景:当您的代码想要访问在运行时计算索引的数组
1 | LDR Ra, [Rb, Rc, <shifter>] |
- 偏移形式为:具有缩放寄存器作为偏移(偏移地址模式)
- Rb 是基寄存器
- Rc 是左/右移位(< shifter >)的即时偏移(或包含即时值的寄存器),以缩放即时偏移(这意味着桶形移位器用于缩放偏移)
- 使用场景:循环以循环访问数组
特殊操作
1 | LDR r3, [r1], #offset |
- 索引后地址模式:这意味着基寄存器(R1)用作最终地址,然后使用 R1 + 4 计算的失调进行更新
- 换句话说,它采用在 R1(不是 R1+4)中找到的值并将其加载到 R3 中,然后将 R1 更新为 R1 + offset
1 | ADR r0, words+12 /* address of words[3] -> r0 */ |
- 我们使用 ADR 指令(懒惰方法)来获取
word[3]
的地址放入 R0 - PS:其实这两个都是伪指令
- ADR 是小范围的地址读取伪指令
- LDR 是大范围的读取地址伪指令
1 | LDR r1, array_buff_bridge /* address of array_buff[0] -> r1 */ |
- 执行上述两条指令后,R1 和 R2 包含
array_buff[0]
和array_buff[2]
的地址 - PS:对于数组,可以采用这种这种写法
同时处理多个值
有时,一次加载(或存储)多个值会更有效,为此,我们使用 LDM(加载多个)和 STM(存储多个)指令:
1 | LDM r0, {r4,r5} /* words[3] -> r4 = 0x03; words[4] -> r5 = 0x04 */ |
- R0 中装有
word[3]
- 我们使用一个命令加载了多个(2个数据块),该命令将:
R4 = 0x00000003
R5 = 0x00000004
- PS:注意在合适的位置打上花括号
1 | stm r1, {r4,r5} /* r4 -> array_buff[0] = 0x03; r5 -> array_buff[1] = 0x04 */ |
- R1 中装有
array_buff[0]
- 我们使用一个命令存储了多个(2个数据块),该命令将:
array_buff[0] = 0x00000003
array_buff[1] = 0x00000004
条件执行
CPSR 中的 N、Z、C 和 V 位与 x86 上 EFLAG 寄存器中的 SF、ZF、CF 和 OF 位相同,它们将用于支持程序集级别的条件和循环中的条件执行
下表列出了可用的条件代码、其含义以及测试的标志的状态:
Condition Code | Meaning (for cmp or subs) | Status of Flags | ||
---|---|---|---|---|
EQ | 相等 | Z==1 | ||
NE | 不相等 | Z==0 | ||
GT | 大于(有符号) | (Z==0) && (N==V) | ||
LT | 小于(有符号) | N!=V | ||
GE | 大于等于(有符号) | N==V | ||
LE | 小于等于(有符号) | (Z==1) \ | \ | (N!=V) |
CS or HS | 大于等于(无符号) | C==1 | ||
CC or LO | 小于(无符号) | C==0 | ||
HI | 大于(无符号) | (C==1) && (Z==0) | ||
LS | 小于等于(无符号) | (C==0) \ | \ | (Z==0) |
MI | 负数 | N==1 | ||
PL | 正数或零 | N==0 | ||
AL | True | – | ||
NV | False | – | ||
VS | 有符号溢出 | V==1 | ||
VC | 无符号溢出 | V==0 |
Thumb 条件执行
ARM 存在多种允许条件执行的 Thumb 版本,某些 ARM 处理器版本支持“IT”指令,该指令允许在 Thumb 状态下有条件地执行最多 4 条指令
1 | IT{x{y{z}}} cond |
- cond 指定 IT 块中第一条指令的条件
- x 指定 IT 块中第二条指令的条件开关
- y 指定 IT 块中第三条指令的条件开关
- z 指定 IT 块中第四条指令的条件开关
IT 指令的结构是 “IF-Then-(Else)”,语法是两个字母 T 和 E 的构造:
- IT 指的是 If-Then(下一个指令是有条件的)
- ITT 指的是 If-Then-Then(接下来的2条指令是有条件的)
- ITE 指的是 If-Then-Else(接下来的2条指令是有条件的)
- ITTE 指的是 If-Then-Then-Else(接下来的3个指令是有条件的)
- ITTEE 指的是 If-Then-Then-Else-Else(接下来的4个指令是有条件的)
IT 块内的每条指令都必须指定一个相同或逻辑相反的条件后缀:
- 如果使用 ITE,则第一条和第二条指令 (If-Then) 必须具有相同的条件后缀,第三条指令 (Else) 必须具有前两条指令的逻辑反比
- 以下是 ARM 参考手册中的一些示例,其中说明了此逻辑:
1 | ITTE NE ; Next 3 instructions are conditional |
跳转&分支
分支(又名Jumps)允许我们跳转到另一个代码段,当我们需要跳过(或重复)代码块或跳转到特定函数时,这很有用
- 这种用例的最佳示例是 IF 和 Loop
有三种类型的分支指令:
- 分支(B)
- 简单跳转到函数
- 分支链接(BL)
- 在 LR 中保存 (PC+4) 并跳转到功能
- 分支交换(BX)和分支链路交换(BLX)
- 与 B/BL 交换指令集相同(ARM <-> Thumb)
- 需要寄存器作为第一个操作数:BX/BLX [reg]
- BX/BLX 用于将指令集从 ARM 交换到 Thumb,示例如下:
1 | .text |
- 这里的诀窍是获取实际 PC 的当前值,将其增加“1”,将结果存储到寄存器,然后执行
BX/BLX [reg]
add r2, pc, #1
:简单地获取有效的PC地址(即当前PC寄存器的值+8 -> 0x805C)并为其添加“1”(0x805C + 1 = 0x805D)
栈和函数
栈
一般来说,堆栈是程序/进程中的内存区域,这部分内存是在创建进程时分配的
- 我们使用 Stack 来存储临时数据,例如某些函数的局部变量,帮助我们在函数之间转换的环境变量等
- 我们使用 PUSH 和 POP 指令与堆栈进行交互,如 PUSH 和 POP(这是其他一些内存相关指令的别名,而不是实际指令,但出于简单起见,我们使用 PUSH 和 POP)
首先,当我们说 Stack 增长时,我们的意思是一个项目(32位数据)被放在 Stack 上,堆栈可以 向上 增长(当堆栈以降序方式实现时)或 向下 增长(当堆栈以上升方式实现时),下一条(32位)信息的实际位置由堆栈指针定义,或者确切地说,由存储在SP寄存器中的内存地址定义
栈地址的增长方向:
- 向高地址增长的栈称为 递增栈(Descendent Stack)
- 向低地址增长的栈称为 递减栈(Acendant Stack)
作为不同 Stack 实现的摘要,我们可以使用下表,其中描述了在不同情况下使用哪些存储多个/加载多个指令:
堆栈类型 | Store | Load |
---|---|---|
完全降序 | STMFD (STMDB,Decrement Before) | LDMFD(LDM,Increment after) |
完全升序 | STMFA (STMIB,Increment Before) | LDMFA (LDMDA,Decrement After) |
空降序 | STMED (STMDA,Decrement After) | LDMED (LDMIB,Increment Before) |
空升序 | STMEA(STM,Increment after) | LDMEA (LDMDB,Decrement Before) |
函数
要理解 ARM 中的函数,我们首先需要熟悉函数的结构部分,它们是:
- Prologue(序幕)
- Body(主体)
- Epilogue(结语)
Prologue(序幕)的目的是保存程序的先前状态(通过将 LR 和 R11 的值存储到堆栈上),并为函数的局部变量设置堆栈(虽然序言的实现可能因使用的编译器而异,但通常这是通过使用 PUSH / ADD / SUB 指令来完成的)
1 | push {r11, lr} /* 序幕的开始,将帧指针和LR保存到堆栈 */ |
Body(主体) 部分通常负责某种独特而特定的任务,这部分函数可能包含各种指令,分支(跳转)到其他函数等,函数的 body 部分的示例可以像以下几条指令一样简单:
1 | mov r0, #1 /* 设置局部变量 (a=1)。 这也用作设置函数 max */ 的第一个参数 |
- 上面的示例代码显示了一个函数的片段,该函数设置局部变量,然后分支到另一个函数
- 这段代码还向我们展示了函数的参数(在本例中为函数 max)是通过寄存器传递的
- 在某些情况下,当有超过4个参数要传递时,我们会另外使用 Stack 来存储剩余的参数
- 还值得一提的是,函数的结果通过寄存器 R0 返回,因此,无论函数(max)的结果是什么,我们应该能够在从函数返回后立即从寄存器 R0 中获取它
- 需要指出的另一件事是,在某些情况下,结果的长度可能是64位(超过32位寄存器的大小)
函数的最后一部分,即 Epilogue(结语),用于将程序的状态恢复到其初始状态(在函数调用之前),以便它可以从它离开的位置继续,为此,我们需要重新调整堆栈指针(这是通过使用帧指针寄存器 R11 作为参考并执行添加或子操作来完成的)
重新调整堆栈指针后,通过将以前(在序幕中)保存的寄存器值从堆栈中弹出到相应的寄存器中来恢复它们,根据函数类型,POP 指令可能是 Epilogue 的最终指令(但是,可能是在恢复寄存器值后,我们使用 BX 指令离开函数),Epilogue 的示例如下所示:
1 | sub sp, r11, #0 /* 结尾的开始,重新调整堆栈指针 */ |
所以现在我们知道:
- Prologue(序幕)为功能设置环境
- Body(主体)实现函数的逻辑并将结果存储到 R0
- Epilogue(结语)还原状态,以便程序可以从调用函数之前离开的位置恢复